Skip to content

Add --remote-auth-scope-param-name for non-standard OAuth scope parameters#4712

Open
gmogmzGithub wants to merge 1 commit intostacklok:mainfrom
gmogmzGithub:fix/slack-oauth-user-scope
Open

Add --remote-auth-scope-param-name for non-standard OAuth scope parameters#4712
gmogmzGithub wants to merge 1 commit intostacklok:mainfrom
gmogmzGithub:fix/slack-oauth-user-scope

Conversation

@gmogmzGithub
Copy link
Copy Markdown

@gmogmzGithub gmogmzGithub commented Apr 9, 2026

Summary

  • Adds --remote-auth-scope-param-name CLI flag to override the query parameter name used for scopes in the OAuth authorization URL
  • When set (e.g., user_scope), scopes are sent under the custom parameter name and the standard scope= is cleared
  • oauth2Config.Scopes is preserved so token refresh continues to work correctly

Motivation

Slack's OAuth v2 requires user-token scopes in user_scope= instead of the standard scope= parameter. When ToolHive connects to https://mcp.slack.com/mcp, its OAuth discovery correctly finds the v2_user/authorize endpoint and the 15 supported scopes, but Go's oauth2 library always places scopes in scope=. Slack rejects this with invalid_scope.

This flag closes the gap so ToolHive can authenticate with providers that use non-standard scope parameter names.

Example usage for Slack MCP

thv run https://mcp.slack.com/mcp \
  --name slack \
  --transport streamable-http \
  --remote-auth-client-id 1601185624273.8899143856786 \
  --remote-auth-callback-port 3118 \
  --remote-auth-authorize-url "https://slack.com/oauth/v2_user/authorize" \
  --remote-auth-token-url "https://slack.com/api/oauth.v2.user.access" \
  --remote-auth-scope-param-name user_scope

Changes

File Change
pkg/auth/oauth/flow.go ScopeParamName field on Config; buildAuthURL override logic
pkg/auth/oauth/manual.go Pass-through scopeParamName parameter
pkg/auth/discovery/discovery.go ScopeParamName on OAuthFlowConfig
pkg/auth/remote/config.go ScopeParamName field with JSON/YAML tags
pkg/auth/remote/handler.go Wire ScopeParamName to flow config
cmd/thv/app/auth_flags.go New --remote-auth-scope-param-name flag
cmd/thv/app/run_flags.go Wire through both config builders
cmd/thv/app/proxy.go Wire through both flow configs

@gmogmzGithub gmogmzGithub force-pushed the fix/slack-oauth-user-scope branch from 1feeca8 to 141b5cf Compare April 9, 2026 18:55
@github-actions github-actions bot added size/S Small PR: 100-299 lines changed and removed size/S Small PR: 100-299 lines changed labels Apr 10, 2026
@codecov
Copy link
Copy Markdown

codecov bot commented Apr 10, 2026

Codecov Report

❌ Patch coverage is 83.33333% with 3 lines in your changes missing coverage. Please review.
✅ Project coverage is 68.62%. Comparing base (a388093) to head (141b5cf).
⚠️ Report is 10 commits behind head on main.

Files with missing lines Patch % Lines
pkg/auth/remote/handler.go 0.00% 2 Missing ⚠️
pkg/auth/discovery/discovery.go 0.00% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #4712      +/-   ##
==========================================
+ Coverage   68.60%   68.62%   +0.02%     
==========================================
  Files         517      517              
  Lines       54631    54639       +8     
==========================================
+ Hits        37480    37498      +18     
+ Misses      14265    14246      -19     
- Partials     2886     2895       +9     

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@gmogmzGithub gmogmzGithub force-pushed the fix/slack-oauth-user-scope branch 2 times, most recently from 2d0496d to 6506bb2 Compare April 12, 2026 18:27
…parameters

Some OAuth providers use non-standard query parameter names for scopes
in the authorization URL. For example, Slack's OAuth v2 requires
user-token scopes in "user_scope" instead of the standard "scope"
parameter. This causes ToolHive's OAuth flow to fail with invalid_scope
errors when connecting to providers like Slack's MCP server.

Add a new --remote-auth-scope-param-name flag that allows users to
override the query parameter name used for scopes. When set, scopes are
sent under the specified parameter name and the standard "scope"
parameter is cleared. The oauth2Config.Scopes field is preserved so
token refresh requests continue to work correctly.

Signed-off-by: Gustavo Gomez <gmogmz@indeed.com>
@gmogmzGithub gmogmzGithub force-pushed the fix/slack-oauth-user-scope branch from 6506bb2 to 849326a Compare April 12, 2026 22:25
Copy link
Copy Markdown
Contributor

@jhrozek jhrozek left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review from 4 specialized agents (oauth-expert, go-security-reviewer, code-reviewer, go-expert-developer). 3 inline comments.

// is preserved so token refresh requests still include scopes correctly.
if f.config.ScopeParamName != "" && len(f.oauth2Config.Scopes) > 0 {
opts = append(opts,
oauth2.SetAuthURLParam("scope", ""),
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

scope= empty parameter persists in the authorization URL

oauth2.SetAuthURLParam("scope", "") does not remove the scope parameter — it sets it to an empty string, producing scope= in the query string. RFC 6749 §3.3 requires scope values to have at least one character, so an explicit scope= is syntactically invalid. Some OAuth providers may reject it.

The test at flow_test.go:274 uses query.Get("scope") which returns "" for both absent and empty-valued params, masking the issue.

One approach: temporarily nil out f.oauth2Config.Scopes before calling AuthCodeURL so the library never adds scope, then restore it:

if f.config.ScopeParamName != "" && len(f.oauth2Config.Scopes) > 0 {
    scopeValue := strings.Join(f.oauth2Config.Scopes, " ")
    savedScopes := f.oauth2Config.Scopes
    f.oauth2Config.Scopes = nil
    defer func() { f.oauth2Config.Scopes = savedScopes }()
    opts = append(opts,
        oauth2.SetAuthURLParam(f.config.ScopeParamName, scopeValue),
    )
}

And update the test assertion to verify truly absent:

_, has := query["scope"]
assert.False(t, has, "scope parameter should be absent, not empty")

config.CallbackPort,
config.Resource,
config.OAuthParams,
config.ScopeParamName,
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ScopeParamName silently ignored on OIDC discovery fallback path

ScopeParamName is correctly passed here on the manual-endpoints path, but the OIDC discovery fallback below (line ~655, CreateOAuthConfigFromOIDC) does not accept or propagate ScopeParamName. A user who sets --remote-auth-scope-param-name with --remote-auth-issuer but without explicit endpoint URLs will get standard scope= behavior with no warning.

Could set it on the returned config after the OIDC call:

cfg, err := oauth.CreateOAuthConfigFromOIDC(ctx, issuer, ...)
if err != nil { return nil, err }
cfg.ScopeParamName = config.ScopeParamName
return cfg, nil

@@ -360,14 +360,15 @@ func handleOutgoingAuthentication(ctx context.Context) (*discovery.OAuthFlowResu
}

flowConfig := &discovery.OAuthFlowConfig{
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: duplicate OAuthFlowConfig construction

The two OAuthFlowConfig struct literals (here and at line 393) are identical — the only difference is the first argument to PerformOAuthFlow. Pre-existing, but this PR extends it by one more field. Consider extracting a helper to avoid future drift.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size/S Small PR: 100-299 lines changed

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants